Amazon S3の画像をAWS Cloudfrontで配信するキャッシュコントロールをAWS Lambdaで自動化する
こんにちは、せーのです。今日はS3の画像をCloudfrontで配信する際のコツを自動化してしまおう、という試みです。
Cloudfrontキャッシュコントロールおさらい
Cloudfrontは静的、動的関わらずキャッシュしてくれるとても便利なCDNサービスですが、Cloudfrontにてキャッシュしてくれる時間(TTL)は基本Cache-Control max-ageヘッダやExpiresにて制御します。更にCloudfront自体の設定にも[minimum TTL]というものがあり、これはヘッダで設定されたキャッシュ時間を強制的に上書きするためのものです。
ここで理解しておきたいのが「ヘッダとminimum TTLのキャッシュ時間の関係」です。詳しくはこちらの記事に書いてありますが、基本的には「キャッシュコントロールはヘッダありき。ヘッダの設定より長く強制的に上書きしたい時にminimum TTLを使う」というのがキャッシュコントロールの思想です。ヘッダがないとデフォルトで24時間のキャッシュ時間が設定されるので、minimum TTLを設定するなら24時間以上を設定しないと意味が無い、ということになります。
S3のキャッシュコントロール
さて、ここから本題です。S3に画像を置いてCloudfrontで配信する。よくあるパターンですね。しかしS3に画像をアップロードしても基本的にはヘッダはつきません。ですのでキャッシュ時間はデフォルトの24時間が適用され、それ以上キャッシュ時間を伸ばしたい時にはminimum TTLを指定して1ヶ月(TTL : 2592000)とか1年間(TTL : 31536000)とか設定する、という運用パターンとなります。 では24時間より短い時間のキャッシュにしたい場合はどうしたら良いでしょう。よく使うのは「Rename Distribution パターン」というCDPパターンに倣い、queryをつけたり日付をファイル名に挟んでApacheのmod_rewriteを使って元に戻す、というような方法ですが、どちらにしてもアプリケーション、またはミドルウェアの実装が必要です。なんとかインフラの設定だけでどうにかできないものでしょうか。できるのです。そう、Lambdaならね。
やりたいこと
ということで、今回やりたいことをまとめます。S3に独自のキャッシュコントロールをつけるには[meta-data]というものが必要です。meta-dataはManagement Consoleでは各オブジェクトの[Property]タブにあります。
キャッシュコントロールの場合はkeyに[Cache-Control]、valueに[max-age=300]という感じに入れていくと設定できます。フォルダ単位のmeta-dataを設定することは出来ませんので、アップロードした各オブジェクトに対してこの作業を一つ一つ行うことになります。うん、めんどくさいですね。
そこでこの作業を一辺に行うようなAWS CLIのワンライナーを弊社スタッフが用意しております。こちらをサクッと流せばバケット単位で一気に同じmeta-dataの設定を流すことができます。ちょっと楽になりました。
$ BUCKET="YOUR_BUCKET_NAME"; aws s3 ls s3://$BUCKET --recursive | awk -v BUCKET="${BUCKET}" '{system("aws s3api copy-object --bucket " BUCKET " --copy-source " BUCKET "/" $4 " --key " $4 " --metadata-directive REPLACE --cache-control \"no-cache, no-store\"")}'
ですが、これは初期構築として画像を全て用意して一気にmeta-dataをつけるには便利ですが、運用時に日々画像が追加される際には毎回このスクリプトを流す必要があります。プログラマであればアップロード処理と合わせてシェル化してしまえばいい、と思うのですが少なくともシェル実行のためのリソースを常に確保しておく必要があります。また実際にアップロードを行うのがデザイナーさんだったりする場合は、そういう複雑な処理を手順に入れてしまうことでヒューマンエラーが起きる可能性が大いに考えられます。
そこで登場するのがLambdaです。LambdaのトリガーにはS3へのオブジェクトのアップロードがありますので、S3に対してデザイナーさんがオブジェクトをアップロードするとLambdaが発火して対象となるオブジェクトにmeta-dataを自動的につける事ができる、はずです。
やってみる
ではやってみたいと思います。まずlambdaのファンクションを作成いたします。lambdaは現在東京リージョンではまだ使えませんのでN.Virsiniaで作ってみます。名前は[cmS3PutMetadataTest]としました。
S3を使うので[S3 GET Object]テンプレートを使用します。
Roleはデフォルトの[lambda_exec_role]を使用します。メモリとタイムアウトはお好みでどうぞ。Functionが完成しましたら対象となるS3のバケットを作成してつなげます。同じN.Virsiniaリージョンで[cm-lambda-test]というバケットを作成し、中にtest1〜test3まで3つのフォルダを作りました。
Lambdaに戻り、作成した[cmS3PutMetadataTest]の[Configure event source]ボタンをクリックし、先ほどの[cm-lambda-test]バケットとつなげます。Invocation RoleはS3の操作ができればよいので[lambda_invoke_role]をそのまま使います。
Functionの中身を編集していきます。[Edit/Test]ボタンをクリックします。出てきたFunctionを修正します。
console.log('Loading event'); var aws = require('aws-sdk'); var s3 = new aws.S3({apiVersion: '2006-03-01'}); exports.handler = function(event, context) { console.log('Received event:'); console.log(JSON.stringify(event, null, ' ')); // Get the object from the event and show its content type var bucket = event.Records[0].s3.bucket.name; var key = event.Records[0].s3.object.key; s3.getObject({Bucket:bucket, Key:key}, function(err,data) { if (err) { console.log('error getting object ' + key + ' from bucket ' + bucket + '. Make sure they exist and your bucket is in the same region as this function.'); context.done('error','error getting file'+err); } else { var params = { Bucket: bucket, /* required */ Key: key, /* required */ Metadata: { cache_control: 'max-age=300' } }; s3.putObject(params, function(err2, data2){ if (err2){ context.done('error','error2 getting file'+err2); }else{ console.log('replace done! Cache-Control : ',data2.CacheControl); } context.done(null,'object meta-data changed.'); }); } } } ); };
アップロードしたバケット名 + パスキーに対してcache_controlのmeta tagをつけてpugObjectしてみました。では実際に[test1]に[test1.txt]をアップロードしてみます。中身を見てみると
よくわからないprefixがついています。これはユーザ定義のmetadataにつくprefixですのでmetadataを書き込むと必ずついてしまうようです。よくよくドキュメントを見てみると[CacheControl]というパラメータがあります。これを使えばよさそうですね。
console.log('Loading event'); var aws = require('aws-sdk'); var s3 = new aws.S3({apiVersion: '2006-03-01'}); exports.handler = function(event, context) { console.log('Received event:'); console.log(JSON.stringify(event, null, ' ')); // Get the object from the event and show its content type var bucket = event.Records[0].s3.bucket.name; var key = event.Records[0].s3.object.key; s3.getObject({Bucket:bucket, Key:key}, function(err,data) { if (err) { console.log('error getting object ' + key + ' from bucket ' + bucket + '. Make sure they exist and your bucket is in the same region as this function.'); context.done('error','error getting file'+err); } else { var params = { Bucket: bucket, /* required */ Key: key, /* required */ CacheControl: "max-age=300" }; s3.putObject(params, function(err2, data2){ if (err2){ context.done('error','error2 getting file'+err2); }else{ console.log('replace done! Cache-Control : ',data2.CacheControl); } context.done(null,'object meta-data changed.'); }); } } } ); };
修正してみました。流してみます。
綺麗に入りました。lambdaの様子はCloudWatch Logsで確認できるので見てみます。
ファイルは一つしかアップしていないのですが、とても沢山のlambdaが発生しているようです。どうしてでしょう。 これはputObjectによってアップロード処理が走るため、その処理に対してlambdaが反応してしまって無限ループのようになっているのかと推測されます。色々と調べて見た結果、Rubyであれば[copy_from]メソッドで回避出来そうな感じがしましたが、lambdaはJavaScript SDKになるため該当するメソッドがありませんでした。代わりにCopyObjectを使ってみたのですが、結果は変わりませんでした。 ですので、ロジック的に回避したいと思います。Cache-Controlヘッダが既に付いているオブジェクトはスキップするように分岐させてみます。ついでですのでCopyObjectでやってみました。
console.log('Loading event'); var aws = require('aws-sdk'); var s3 = new aws.S3({apiVersion: '2006-03-01'}); exports.handler = function(event, context) { console.log('Received event:'); console.log(JSON.stringify(event, null, ' ')); // Get the object from the event and show its content type var bucket = event.Records[0].s3.bucket.name; var key = event.Records[0].s3.object.key; s3.getObject({Bucket:bucket, Key:key}, function(err,data) { if (err) { console.log('error getting object ' + key + ' from bucket ' + bucket + '. Make sure they exist and your bucket is in the same region as this function.'); context.done('error','error getting file'+err); } else { console.log('logging Cache-Control : ',data.CacheControl); if (typeof data.CacheControl != 'undefined'){ console.log('cache control was already exists.'); context.done(null,'skip execution.'); }else { var params = { Bucket: bucket, /* required */ CopySource: bucket + "/" + key, Key: key, /* required */ CacheControl: "max-age=300", MetadataDirective: "REPLACE" }; console.log('replace object.'); console.log('bucket : ' + bucket); console.log('CopySource : ' + bucket + "/" + key); s3.copyObject(params, function(err2, data2){ if (err2){ context.done('error','error2 getting file'+err2); }else{ console.log('replace done! Cache-Control : ',data2.CacheControl); } context.done(null,'object meta-data changed.'); }); } } } ); };
修正が終わりました。ではこれで流してみましょう。
いくつファイルを入れても、ヘッダが付いていることが確認できます。
ログを見てみても2回目はスキップしているようです。最後にフォルダごとにTTL時間を変えてみたいと思います。
console.log('Loading event'); var aws = require('aws-sdk'); var s3 = new aws.S3({apiVersion: '2006-03-01'}); exports.handler = function(event, context) { console.log('Received event:'); console.log(JSON.stringify(event, null, ' ')); // Get the object from the event and show its content type var bucket = event.Records[0].s3.bucket.name; var key = event.Records[0].s3.object.key; s3.getObject({Bucket:bucket, Key:key}, function(err,data) { if (err) { console.log('error getting object ' + key + ' from bucket ' + bucket + '. Make sure they exist and your bucket is in the same region as this function.'); context.done('error','error getting file'+err); } else { console.log('logging Cache-Control : ',data.CacheControl); if (typeof data.CacheControl != 'undefined'){ console.log('cache control was already exists.'); context.done(null,'skip execution.'); }else { var ttl = 0; if ( key.match(/test1/)) { ttl = 300; } if ( key.match(/test2/)) { ttl = 600; } if ( key.match(/test3/)) { ttl = 900; } var params = { Bucket: bucket, /* required */ CopySource: bucket + "/" + key, Key: key, /* required */ CacheControl: "max-age=" + ttl, MetadataDirective: "REPLACE" }; console.log('replace object.'); console.log('bucket : ' + bucket); console.log('CopySource : ' + bucket + "/" + key); s3.copyObject(params, function(err2, data2){ if (err2){ context.done('error','error2 getting file'+err2); }else{ console.log('replace done! Cache-Control : ',data2.CacheControl); } context.done(null,'object meta-data changed.'); }); } } } ); };
では、流してみます。
ちゃんとフォルダ毎にTTLが分かれてくれました。
まとめ
いかがでしたか。例えば画像ファイル、JSファイル、CSSファイルを分ける際や、minimum TTLでは制御出来ないほど短い時間でのキャッシュコントロールをしたい時に参考にしてみてください。